/*******************************************************************************
 * Copyright (c) 2011 Gijs de Vries aka Janoz.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 * 
 * Contributors:
 *     Gijs de Vries aka Janoz - initial API and implementation
 ******************************************************************************/
/* COPYRIGHT (C) 2008 Gijs de Vries. All Rights Reserved. */
package com.janoz.httpd;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.Set;

/**
 * Http daemon for handling http requests.
 * 
 * @author Gijs de Vries 
 * TODO: Adjusting of session cookie name and lifetime
 */

public class Httpd {

	private static final String COOKIENAME = "JSESSID";
	private static final int SESSID_LENGTH = 10;
	private static final long LIFETIME = 1000 * 60 * 20;
	private static final long COOKIE_GC_INTERVAL = 5 * 60 * 1000;
	private static final String VERSION = 
		Httpd.class.getPackage().getSpecificationVersion();


	private static Random rand = new Random();
	private int port = 80;
	private String serverName = "Janoz' PicoServer (" + VERSION + ")";
	private RequestHandler handler;
	private String username;
	private String password;
	private InetAddress bindTo;
	private Set<InetAddress> acceptedHosts = new HashSet<InetAddress>();
	private Map<String, Httpd.Session> sessions = 
		new HashMap<String, Httpd.Session>();

	private boolean running = true;
	private Thread sessionCleaner;
	private ServerSocket serverSocket;
	private Thread socketAccepter;
	
	/**
	 * 
	 * @param handler the handler that will handle requests
	 */
	public void setHandler(RequestHandler handler) {
		this.handler = handler;
	}
	
	/**
	 * Sets the port to listen on. Will not have any affect 
	 * when the server is already running.
	 * @param port the port to listen on.
	 */
	public void setPort(int port) {
		this.port = port;
	}

	/**
	 * 
	 * @return the port to listen
	 */
	public int getPort() {
		return port;
	}

	/**
	 * 
	 * @param serverName The name of the server
	 */
	public void setServerName(String serverName) {
		this.serverName = serverName;
	}
	
	/**
	 * When username and password are null the site is not password
	 * protected. Otherwise a login screen will appear. 
	 * 
	 * @param password The password. Changing it does not have any 
	 * effect on existing sessions.
	 */
	public void setPassword(String password) {
		this.password = password;
	}
	
	/**
	 * When username and password are null the site is not password
	 * protected. Otherwise a login screen will appear. 
	 * 
	 * @param username The username.. Changing it does not have any 
	 * effect on existing sessions.
	 */
	public void setUsername(String username) {
		this.username = username;
	}
	
	/**
	 * Bind the HTTP daemon to a specific interface.
	 * The <i>bindTo</i> argument
     * can be used on a multi-homed host so Httpd
     * will only accept connect requests to one of its addresses.
     * If <i>bindAddr</i> is null, it will default accepting
     * connections on any/all local addresses.
     * 
	 * @param bindTo internet adress to bind to or null to accept all
	 */
	public void setBindTo(InetAddress bindTo) {
		
		final byte[] zeroIp = new byte[]{0,0,0,0};
		if (bindTo == null || Arrays.equals(bindTo.getAddress(), zeroIp)) {
			this.bindTo = null;
		} else {
			this.bindTo = bindTo;
		}
	}
	
	public InetAddress getBindTo(){
		return bindTo;
	}

	/**
	 * Restarts the serverSocket to reconfigure the server.
	 * @throws IOException when IO errors occur.
	 */
	public void restart() throws IOException {
		stop();
		start();
	}
	
	
	/**
	 * Stops the server.
	 * @throws IOException When an IO Error occurs while stopping
	 */
	public void stop() throws IOException {
		running = false;
		if (socketAccepter== null || sessionCleaner == null || serverSocket == null) {
			//probably never started
			System.err.println("Tried to stop a server that wasn't started (correctly).");
			return;
		}
		sessionCleaner.interrupt();
		socketAccepter.interrupt();
		serverSocket.close();
		try {
			sessionCleaner.join();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		try {
			socketAccepter.join();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
	/**
	 * Starts the server. Creates garbage collecting thread for 
	 * cleaning up sessions and a thread for accepting incoming
	 * requests. Both threads are started as daemon threads.
	 * 
	 * @throws IOException when IO errors occur.
	 */
	public void start() throws IOException {
		try {
			running = true;
			constructSessionCleaner();
			constructServerSocket();
			constructSocketAccepter();
		} catch (IOException e) {
			System.err.println("Error accepting requests");
			throw e;
		}
	}

	private void constructSocketAccepter() {
		socketAccepter = new Thread(new Runnable() {
			public void run() {
				while (running) {
					try {
						new Httpd.RequestProcessor(serverSocket.accept());
					} catch (IOException e) {
						if (running){
							System.err.println("Error accepting requests");
							e.printStackTrace();
						}
					}
				}
			}
		});
		socketAccepter.setDaemon(true);
		socketAccepter.start();
	}

	private void constructSessionCleaner() {
		sessionCleaner = new Thread(new Runnable() {
			public void run() {
				Date date;
				while (running) {
					try {
						Thread.sleep(COOKIE_GC_INTERVAL);
					} catch (InterruptedException e) {
						/* eat it */
					}
					date = new Date(System.currentTimeMillis() - LIFETIME);
					for (String sessionId : sessions.keySet()) {
						if (date.after(sessions.get(sessionId).touched)) {
							sessions.remove(sessionId);
						}
					}
				}
			}
		});
		sessionCleaner.setDaemon(true);
		sessionCleaner.start();
	}



	private void constructServerSocket() throws IOException {
		serverSocket = new ServerSocket(port,50,bindTo);

		if (port == 0) { //0 means any free port so update port
			port = serverSocket.getLocalPort();
		}
	}

	/**
	 * 
	 * @param adress the address to add to the accepted hosts
	 */
	public void addAcceptedHost(InetAddress adress) {
		acceptedHosts.add(adress);
	}

	/**
	 * removes all accepted hosts. If there are no 
	 * accepted hosts, everyone has access.
	 *
	 */
	public void clearAcceptedHost() {
		acceptedHosts.clear();
	}

	/**
	 * Class to hold session information.
	 */
	static class Session {
		private Date touched;

		private boolean authenticated;

		/**
		 * Constructor that contains the session information.
		 *
		 */
		Session() {
			touched = new Date();
			authenticated = false;
		}
	}

	/**
	 * Inner class to process a single request.
	 */
	class RequestProcessor implements Runnable {
		private Socket socket;

		/**
		 * Constructs the processor and starts a new thread to
		 * handle the request.
		 * 
		 * @param s Incoming socket which is doing a requests
		 * @throws IOException when an IO exception occures
		 */
		RequestProcessor(Socket s) throws IOException {
			if (!acceptedHosts.isEmpty()
					&& !acceptedHosts.contains(s.getInetAddress())) {
				new Response(StatusCode.FORBIDDEN).write(s.getOutputStream());
				s.close();
				return;
			}
			socket = s;
			Thread t = new Thread(this);
			t.setDaemon(true);
			t.start();
		}

		/**
		 * @see java.lang.Runnable#run()
		 */
		public void run() {
			try {
				//used data
				String uri;
				String method;
				Properties params = new Properties();
				Response r = null;
				String sessionId = null;
				int contentlength = 0;

				//get stream
				InputStream is = socket.getInputStream();
				BufferedReader br = new BufferedReader(
						new InputStreamReader(is));

				// parse first line
				String line = br.readLine();
				if (line==null) throw new IOException("Unexpected end of request.");
				String[] req = line.split(" ");
				method = req[0];
				uri = req[1];

				// parse headers (cookie and content-length)
				do {
					line = br.readLine();
					if (line == null) {
						throw new IOException("Unexpected end of stream.");
					}
					int colonpos = line.indexOf(':');
					if (colonpos > 0) {
						if ("cookie".equalsIgnoreCase(line.substring(0,
								colonpos).trim())) {
							sessionId = stripSessionId(line
									.substring(colonpos + 1));
						}
						if ("content-length".equalsIgnoreCase(line.substring(0,
								colonpos).trim())) {
							try {
								contentlength = Integer.parseInt(line
										.substring(colonpos + 1).trim());
							} catch (NumberFormatException e) {
								// just eat it
							}
						}
					}
				} while (line.length() > 0);
				
				// create or retrieve session
				Session session = sessions.get(sessionId);
				if (session == null) {
					// NEW SESSION
					sessionId = createSessionId();
					session = new Session();
					sessions.put(sessionId, new Session());
				} else {
					// EXCISTING SESSION
					session.touched = new Date();
					sessionId = null;
				}

				//check for login attempt
				if ("POST".equals(method) && username != null
						&& password != null) {
					// a possible login attempt
					char[] buf = new char[contentlength];
					br.read(buf);
					Properties authProps = extractParams(new String(buf));
					if (username.equals(authProps.getProperty("username"))
							&& password.equals(authProps
									.getProperty("password"))) {
						session.authenticated = true;
					}
				}
				
				//check if login is required
				if (username != null && !"".equals(username)
						&& !session.authenticated) {
					r = new Response(PAGEPRELOGIN + uri + PAGEPOSTLOGIN);
				} else {
					// all is well, handle request
					
					//parse get parameters
					int qmpos = uri.indexOf('?');
					if (qmpos >= 0) {
						params = extractParams(uri.substring(qmpos + 1));
						uri = uri.substring(0, qmpos);
					}
					//let the handler handle the request
					r = handler.handleRequest(uri, params);
				}
				//send the result to the client
				r.setServerName(serverName);
				r.write(socket.getOutputStream(), sessionId);
			} catch (IOException e) {
				System.err.println("Error handling request");
			} finally {
				if (!socket.isClosed()) {
					try {
						socket.close();
					} catch (IOException e) {
						System.err.println("Error closing socket..");
					}
				}
			}
		}

		private Properties extractParams(String queryString) {
			if (queryString == null || queryString.length() == 0) {
				return null;
			} else {
				Properties result = new Properties();
				String[] params = queryString.split("&");
				int eqpos;
				for (int i = 0; i < params.length; i++) {
					eqpos = params[i].indexOf('=');
					result.setProperty(params[i].substring(0, eqpos),
							URLDecoder.decode(params[i].substring(eqpos + 1)));
				}
				return result;
			}
		}

		private String stripSessionId(String line) {
			String[] cookies = line.split(";");
			int eqpos;
			for (int i = 0; i < cookies.length; i++) {
				eqpos = cookies[i].indexOf('=');
				if (cookies[i].substring(0, eqpos).trim().equals(COOKIENAME)) {
					return cookies[i].substring(eqpos + 1).trim();
				}
			}
			return null;
		}

		private String createSessionId() {
			char[] chars = new char[SESSID_LENGTH];
			for (int i = 0; i < SESSID_LENGTH; i++) {
				chars[i] = (char) ('A' + rand.nextInt('Z' - 'A'));
			}
			return new String(chars);
		}

		private static final String PAGEPRELOGIN = "<html>"
				+ "<head>"
				+ "<title>Login</title>"
				+ "<style>"
				+ "body{font-family: Arial, Helvetica, sans-serif;"
				+ "height : 100%}"
				+ "label{width:6em;}"
				+ "input{width:9em;}"
				+ ".button{position : absolute;left : 50%;"
				+ "width : 6em;margin-left : -3em;}"
				+ "fieldset{top : 50%;position : absolute;left : 50%;"
				+ "margin-top : -6em;margin-left : -8em;padding-bottom : 2em;"
				+ "width : 16em;}"
				+ "</style>" + "</head>" + "<body>"
				+ "<form method=\"post\" action=\"";

		private static final String PAGEPOSTLOGIN = "\">"
				+ "<fieldset>"
				+ "<legend>Login</legend>"
				+ "<label for=\"username\">Username</label>"
				+ "<input id=\"username\" name=\"username\" type=\"text\" />"
				+ "<br />"
				+ "<label for=\"password\">Password</label>"
				+ "<input id=\"password\" name=\"password\" "
				+ "type=\"password\" />"
				+ "<br />"
				+ "<input class=\"button\" type=\"submit\" value=\"login\" />"
				+ "</fieldset>" + "</form>" + "</body>";
	}
}
